Skip to content

Go 安全工具选型:先分清库、扫描器和密钥服务 ​

jwt、go-oidc、Casbin 和 Vault 都可能出现在登录系统里,但它们没有在解决同一个问题。JWT 库验证令牌格式和声明,OIDC 客户端处理联合身份的一部分,策略引擎判断权限,密钥服务管理秘密和密码学操作。扫描器则从旁检查代码或制品,不参与每一次业务授权。

本文逐一说明九个工具或工具类别:golang-jwt/jwt、golang.org/x/crypto、Casbin、go-oidc、govulncheck、gosec、Trivy、Vault、云厂商 KMS。选型重点是它负责哪段边界、调用方还欠哪些工作,以及测试结果能说明什么。

先按任务选入口 ​

工具放在系统哪里主要入口或产物解决的问题不能替代的工作
golang-jwt/jwtGo 进程内ParseWithClaims、签名方法、验证选项JWT 编解码、验签、声明校验登录协议、刷新吊销、对象授权
x/cryptoGo 进程内Argon2、bcrypt 等具体包标准库之外的密码学实现参数设计、密钥管理、完整协议
Casbin授权执行点Model、Policy、Enforce按模型计算是否允许认证、可信属性、事务、所有入口覆盖
go-oidcOIDC 客户端Provider、Verifier、RemoteKeySet发现、密钥获取、ID Token 验证完整回调事务、PKCE/state/nonce生命周期
govulncheckCI/检查阶段已知漏洞及可达性结果Go 依赖与标准库已知漏洞分析业务逻辑、未知漏洞、全部动态路径
gosecCI/代码审查规则发现与分析错误Go 源码中的危险模式运行时证明、完整人工审计
Trivy制品与部署检查漏洞、配置、secret、SBOM 等报告多种制品与组件视角检查精确业务调用链、自动证明漏洞可利用
Vault独立服务KV、动态凭据、Transit、租约与审计秘密发放和部分密码学服务客户端身份、安全运维、业务授权
云厂商 KMS云控制面/密码学服务Key、Encrypt/Decrypt、Sign、Wrap 等托管密钥与受控密码学操作通用秘密存储、全部账号风险、数据重加密

同一列比较的是相同维度。不要用“Vault 比 jwt 更安全”这类说法选型,它们可能正好需要一起使用。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    I["可信身份提供方"] --> O["go-oidc:验证登录身份"]
    O --> S["本应用会话与主体"]
    J["jwt:验证访问令牌"] --> S
    S --> C["Casbin:主体、动作、资源策略"]
    C --> B["业务操作"]
    X["crypto 与 x/crypto:密码学组件"] --> B
    K["Vault / KMS:秘密与密钥能力"] --> X
    Q["govulncheck / gosec / Trivy"] -. "检查代码、依赖和制品" .-> B
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    I["可信身份提供方"] --> O["go-oidc:验证登录身份"]
    O --> S["本应用会话与主体"]
    J["jwt:验证访问令牌"] --> S
    S --> C["Casbin:主体、动作、资源策略"]
    C --> B["业务操作"]
    X["crypto 与 x/crypto:密码学组件"] --> B
    K["Vault / KMS:秘密与密钥能力"] --> X
    Q["govulncheck / gosec / Trivy"] -. "检查代码、依赖和制品" .-> B

本文固定了什么,实际验证了什么 ​

查证与运行日期为 2026-10-13,工具链是官方 Go 1.27.2、Linux/amd64。下面是复现版本,不是对未来版本的推荐保证。

项目固定版本或来源本文实际状态
golang-jwt/jwt/v5v5.3.1RS256 正常和拒绝路径已运行
golang.org/x/cryptov0.58.0Argon2id 接口已运行
github.com/casbin/casbin/v2v2.135.0租户/对象/动作策略与撤销已运行
github.com/coreos/go-oidc/v3v3.21.0本地静态公钥 ID Token 验证已运行
govulncheckv1.9.0,官方漏洞数据库源码扫描成功,含模块级已知问题,详见后文
gosecv2.29.0,官方固定 tag 构建实际尝试扫描,但因 Go 导出数据格式不兼容失败
Trivy / Vault / KMS官方文档查证未安装或连接服务,未做实际扫描或云操作

库版本来自 go.mod/go.sum,扫描器模块版本用 go version -m 核验。这里只使用公开测试输入和临时随机密钥,没有真实账户或凭据。

golang-jwt/jwt:库能验哪些东西,哪些要你写出来 ​

jwt.NewWithClaims 加签名方法创建令牌,SignedString 生成紧凑表示;验证时用 ParseWithClaims,给定受信密钥回调和明确选项。最危险的误用之一,是让未验证的 alg 决定可接受算法,或者只解码 payload 就把里面的用户 ID 当成已认证主体。v5.3.1 parser

本例把算法固定为 RS256,预期发行者和受众也来自配置。密钥回调只返回预先生成的测试公钥,并检查自定义令牌类型。这个自定义 profile 不声称符合 RFC 9068 的 at+jwt 标准。

go
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithIssuer(issuer),
jwt.WithAudience("orders-api"),
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
text
jwt-valid: true
jwt-wrong-audience: false

第二张令牌也由正确私钥签名,但发给 other-api,所以必须拒绝。测试还尝试用 HS256 令牌进入 RS256 验证路径。WithExpirationRequired 明确要求 exp 存在;WithIssuedAt 开启签发时间检查,业务必填的 sub/jti/iat/nbf 仍由示例补查。固定版本选项

该库不替你维护刷新令牌、撤销表或账号停用状态。jti 只是标识,除非与服务端状态结合,不会自动防重放。它也不会因为令牌里有 role=admin 就知道角色是否仍有效。密钥选择应绑定预配置发行者,不能根据输入的 jku、x5u 或 kid 随意联网或读文件。

算法名还随规范演进。RFC 9864 已弃用多态 JOSE 标识 EdDSA,新增全指定 Ed25519、Ed448;库是否支持这些标识必须按具体版本确认。本例选择支持明确的 RS256,只验证接口行为,不声称其他算法在本环境中都经过验证。RFC 8725、RFC 9864 §2.2

x/crypto:选择具体包,不给整个模块贴安全标签 ​

golang.org/x/crypto 是一组密码学相关包,不是“导入后自动加密”的框架。常见入口包括 argon2.IDKey、bcrypt 的生成/比较函数,以及某些标准库之外的协议或算法实现。标准库已有合适入口时,先明确为何还需要额外包;使用子包前要核对维护状态。

本例实际派生一个 32 字节结果:

go
dk := argon2.IDKey(password, salt, 2, 19*1024, 1, 32)
text
argon2-output-bytes: 32

这只说明固定版本接口按所选参数返回了预期长度。随机盐由调用方生成;算法、版本、参数、盐和结果的持久化格式也由应用管理。19 MiB、2 次、并行度 1 对应当前 OWASP 的一组最低起点,生产仍要测试内存、延迟、并发、参数上限和登录限流。argon2 v0.58.0、OWASP Password Storage

同一模块里的另一个包可能不适合新项目。本文扫描就遇到了 x/crypto/openpgp 的模块级告警:官方报告明确它未维护、设计不安全、没有已知修复版本。本例只用 Argon2id,没有导入 openpgp;不能因为模块名字相同就断言 Argon2id 命中了同一个漏洞,也不能因此忽略业务仓库里是否有人用了那个子包。GO-2026-5932

Casbin:把策略从业务代码里分离出来,但不能分离责任 ​

Casbin 的 Model 规定请求、策略、角色关系和匹配逻辑;Policy 是具体规则;Enforcer 把输入代入模型返回允许/拒绝与错误。适合权限规则需要独立维护、审查和测试的项目。规则很少时,清楚的本地函数也可能比引入引擎更容易理解。

本例的请求是 sub, dom, obj, act:主体、租户、带可信租户属性的对象、动作。角色关系也带域,避免把 team-a 的角色无意扩展到 team-b。匹配规则首先比较请求租户与对象真实租户,再检查该域下的角色和具体对象、动作。Casbin 域 RBAC

text
m = r.dom == r.obj.Tenant && g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj.Name == p.obj && r.act == p.act

加载一条“team-a 的 reader 可以读取 doc-1”策略,再把 Alice 加入这个域的 reader,实际输出:

text
casbin-read: true
casbin-cross-tenant: false
casbin-delete: false

这里 Resource.Tenant 必须来自服务端对象记录,sub/dom 必须来自可信身份与成员关系,不能从请求 JSON 原样抄入。策略匹配通过只说明这些输入满足规则,不证明输入是真的。真实对象所有权、状态和共享关系是否要纳入规则,要根据业务继续补充。Casbin ABAC

没有匹配的 allow 规则就拒绝,这是本例 model 的效果,并非所有自定义 model 天生相同。Enforce 返回错误时不能按“没算出来”放行。动态加载策略还要解决存储 adapter、多副本通知、缓存失效、并发访问和审计;测试只验证本地内存移除角色后立即拒绝,没有测分布式撤销。

go-oidc:验证 ID Token,不会替你完成整次登录 ​

通常先用受信发行者地址创建 Provider,再从 Provider 得到端点和 Verifier;授权码交换通常由 golang.org/x/oauth2 配合完成。go-oidc 能处理发现文档、受信 JWKS 和 ID Token 验证,但不是一个拿来即用的完整账户系统。go-oidc 固定版本

本例刻意不用外部 Discovery 或 JWKS。用 oidc.StaticKeySet 放一个本地 RSA 公钥,创建指定 issuer、ClientID 和算法的 Verifier:

go
oidc.NewVerifier(issuer,
    &oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{pub}},
    &oidc.Config{ClientID: "web-client",
        SupportedSigningAlgs: []string{"RS256"},
        Now: func() time.Time { return now }})
text
oidc-valid: true
oidc-wrong-nonce: false

第二行的拒绝来自应用包装函数,不是声称 Verifier 自动知道浏览器会话保存的 nonce。库返回 IDToken.Nonce,应用还要与本次事务的期望值匹配,并原子消费事务。示例也补查了主体、签发时间、严格到期边界和适用的 azp;v3.21.0 源码明确说明 ClientID 受众检查不等同于验证 authorized party。verify.go

真实回调还需要 PKCE、state/nonce 绑定、回调地址精确匹配、一次性 code、错误处理和安全会话创建。不要为了接通测试关掉 issuer、audience、expiry 或 signature 检查。远程密钥获取要配置超时、限制可信发行者、处理轮换和缓存;未知 key ID 不能变成随意对外请求的能力。OIDC Core 验证要求、RFC 9700

本文没有测真实身份提供方、网络密钥轮换、授权码交换或浏览器回调;仅本地签名互通通过,不等于上线登录流程通过。

三种扫描器:看见的是三种不同材料 ​

比较维度govulncheckgosecTrivy
核心输入Go 源码包、模块与可支持的二进制Go 源码和类型/数据流分析结果镜像、文件系统、仓库、SBOM 等目标
核心依据Go 漏洞数据库与调用分析固定版本规则集与分析器漏洞库、组件识别、配置和 secret 规则
更适合发现已知 Go 漏洞是否影响当前代码不安全 API 用法或危险代码模式系统包/语言依赖、配置和秘密暴露等
常见误判风险将模块命中直接当成当前函数可利用将模式命中直接当成可利用漏洞将包版本命中当成完整应用风险结论
常见漏看风险未知漏洞、动态调用和分析边界规则未覆盖、加载失败、复杂语义组件识别与数据库覆盖不足
结果有效前提加载成功、范围和构建配置正确分析成功、规则与工具链兼容目标正确、扫描器与数据库可用

govulncheck:这次真的出现了“模块有问题,但没有调用命中” ​

对文末完整 Go 模块运行固定 govulncheck v1.9.0 -show verbose ./...,2026-10-13 的实际报告包括:

text
=== Symbol Results ===
No vulnerabilities found.

=== Module Results ===
Vulnerability #1: GO-2026-5932
  Module: golang.org/x/crypto
    Found in: golang.org/x/crypto@v0.58.0
    Fixed in: N/A

报告末尾说明:当前代码受影响符号计数为 0,导入包层面为 0,所要求模块层面为 1。官方漏洞条目指向未维护的 openpgp 子包;本例使用 Argon2id,扫描没有识别出受影响调用。Go 漏洞管理

因此不能把上面第一行截出来写成“全部安全”,也不应把模块告警改写成“Argon2id 被证实不安全”。应该保留层次、版本、数据库时间和适用性理由。此次数据库更新时间为 2026-10-08 22:31:09 UTC,退出码为 0;这既展示了工具的范围区分,也说明退出码需要结合工具语义解读。

gosec:本次失败本身就是一个值得保留的结果 ​

gosec 的规则包括检查不安全 TLS 配置等内容。实验准备了一个永不被调用的函数,唯一用途是静态扫描输入:

go
func NeverUseThisConfig() *tls.Config {
    return &tls.Config{InsecureSkipVerify: true}
}

这里没有建立任何连接,也没有改动系统配置。按固定版本规则目录,本来是用来检验 G402 的夹具。gosec v2.29.0 规则

但本次官方 tag 构建的 gosec v2.29.0,在 Go 1.27.2 下加载 crypto/tls 时失败,实际错误关键部分为:

text
export data version 5 is greater than maximum supported version 4

JSON 报告的 Stats.found 是 0,Golang errors 非空,进程退出码 1。这次没有得到可信的 G402 检查结果。 若流水线只读“发现数 0”,就会把根本没分析成功的扫描误判成通过。

本例保留失败,不私改扫描器依赖后仍声称是官方版本。实际项目应选择明确兼容目标工具链的发布版本,在升级流水线前用已知好/坏夹具做回归,并同时检查加载错误、扫描覆盖、退出码和发现数。本次 gosec -version 打印 dev,因为 go install 构建没有注入发布元数据;模块版本依据 go version -m 中的 github.com/securego/gosec/v2 v2.29.0 核验。

规则发现需要人工结合上下文确认。合理抑制应带规则 ID、具体理由和复查条件,不能全仓库关闭一类告警。工具还带可选 AI 自动修复入口;本文没有启用它,没有把源码发送给外部 AI 服务。gosec 项目说明

Trivy:把检查延伸到最终镜像和部署材料 ​

Trivy 可以针对镜像和文件系统执行相应扫描。漏洞扫描识别组件与受影响版本;misconfiguration 检查部署配置;secret 扫描按规则寻找疑似秘密;SBOM 功能帮助记录和再次扫描组件清单。应显式选择任务需要的扫描器,而不是只记住一个命令名字。Trivy 官方指南

扫描镜像时优先使用最终部署的 digest,记录操作系统、软件源、工具版本和数据库更新时间。发行版可能回移植修复,不能只拿上游版本字符串手工判断;同一个 CVE 在不同供应商源中的严重性与修复状态也可能不同。Trivy 漏洞扫描说明

secret 扫描能发现一些已知格式和模式,不能证明任何秘密都不存在;漏报、误报和已删除历史层都要按目标范围判断。发现真实 secret 后应撤销或轮换,删除一行文本不是消除暴露。报告本身也可能含敏感片段,应限制访问。Trivy secret scanner

本文只查证文档,未安装 Trivy,也没有目标镜像。因此文末给的是待验证命令形态,不提供伪造扫描输出、兼容版本或性能比较。

Vault:存秘密、发凭据、做密码学操作,是不同引擎 ​

Vault 的 KV 适合保存版本化秘密;数据库 secrets engine 可以按配置生成或管理数据库凭据;Transit 提供加解密、签名等操作。选择引擎时先问应用究竟需要拿到秘密文本,还是只需要一次解密/签名能力,后者不一定要把长期密钥材料交给应用。Vault 文档、数据库引擎、Transit

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant A as 工作负载
    participant V as Vault
    participant D as 目标数据库或数据服务
    A->>V: 用已配置工作负载身份认证
    V->>V: 按策略判断允许的路径和能力
    V-->>A: 有租期的凭据或允许的密码学结果
    A->>D: 使用最小权限凭据处理请求
    A->>V: 按策略续租或停止使用
    V->>D: 适用时撤销动态凭据
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant A as 工作负载
    participant V as Vault
    participant D as 目标数据库或数据服务
    A->>V: 用已配置工作负载身份认证
    V->>V: 按策略判断允许的路径和能力
    V-->>A: 有租期的凭据或允许的密码学结果
    A->>D: 使用最小权限凭据处理请求
    A->>V: 按策略续租或停止使用
    V->>D: 适用时撤销动态凭据

有租约不代表客户端可以忘记生命周期。应用要处理续租失败、过期、撤销和连接池中已建立连接的行为;不同后端的撤销效果也要实测。不要把 Vault 的 root token 放进应用来换取“集成简单”,也不要给所有工作负载读全路径权限。租约与撤销、策略

自建 Vault 增加了高可用、存储、备份、初始化与解封、升级和审计责任。托管方式会改变运维分工,但不会自动修复过宽策略。本文没有创建 Vault 实例、生成 token、接入数据库或测试租约;这些是选型时必须补的验证。

云厂商 KMS:少分发原始密钥,仍要限制谁能调用 ​

AWS KMS、Google Cloud KMS、Azure Key Vault/Managed HSM 等服务有各自的资源、权限、算法与配额。它们不能仅因为都叫“密钥服务”就按同一 API 或同一轮换语义使用。KMS 通常管理密钥并执行受控密码学操作;任意数据库密码、第三方 token 的保存与自动轮换,可能还需要专门的 Secret 管理服务。

选型问题需要核对的内容忽略后的后果
应用需要什么能力加解密、签名、MAC、封装,算法与数据量限制选到不能满足协议的密钥类型
谁能使用密钥工作负载身份、IAM/策略、加密上下文无原始密钥也能通过接口大量解密
数据在哪里区域、复制、跨区恢复、驻留约束故障时无法解密或违反数据边界
轮换意味着什么新版本、旧版本可用性、应用如何切换以为轮换后历史数据已重加密
网络或额度不足怎么办延迟、配额、重试、缓存与可用性把 KMS 故障放大成全站阻塞
如何调查调用审计记录、调用主体、key ID、异常告警无法确认谁用了密钥

信封加密通常用数据密钥 DEK 加密业务内容,用 KMS 的密钥加密密钥 KEK 包装 DEK。这样大量数据处理可以在应用内完成,KMS 负责控制解开数据密钥的能力;应用内短时存在的明文 DEK 仍需要保护。Google Cloud 信封加密、AWS KMS 概览、Azure Key Vault 概览

轮换不等于历史数据自动迁移。例如 AWS 官方明确说明 KMS key rotation 不会轮换其生成的数据密钥,也不会重加密已保护的数据。Google Cloud 的轮换也需要应用按版本与用途设计后续操作。若密钥已泄漏,仅开启未来轮换不一定减轻已泄漏数据密钥的风险。AWS 轮换说明、Google Cloud 轮换

本文没有连接云账户,没有生成或传递真实凭据,也没有实测任何供应商的吞吐、延迟、费用或合规等级。选型时这些必须按区域、密钥类型和真实负载核实,不能从 SDK 方法名推出结论。

把工具组合成可以失败的检查链 ​

库层测试要包含正常输入和拒绝路径;扫描层要分别识别“没有发现”“存在告警”和“根本没完成”;服务层要验证权限、轮换、故障和恢复。先规定每层失败时发生什么,再决定 CI 是否阻断、何时允许有期限的例外。

本文的两个扫描结果恰好说明这个区分:govulncheck 成功分析并报告一个未被导入调用的模块级问题;gosec 则连类型数据都没读成功。它们都出现过“0”这个数字,含义完全不同。选择工具时,能解释输出和局限,比把工具列表列长更重要。

依据与验证边界 ​

  • 库:jwt v5.3.1 的 parser/选项,x/crypto v0.58.0 argon2,Casbin v2.135.0 及官方域模型文档,go-oidc v3.21.0 verify.go/jwks.go;完整依赖校验值见文末 go.sum。
  • 协议:RFC 8725、9864、9700、OIDC Core;本文的访问令牌 profile 是本地教学约定,不声称通用标准兼容。
  • 扫描:govulncheck v1.9.0、官方 GO-2026-5932;gosec v2.29.0 固定规则与实际加载错误;Trivy 官方扫描器/目标文档,未实测。
  • 密钥服务:Vault 引擎、策略和租约官方文档;AWS、Google Cloud、Azure 官方密钥服务与轮换文档,未接入真实服务。
  • 2026-10-13,Go 1.27.2、Linux/amd64,实际通过 go run .、go test -race -count=1 ./...、go vet ./...、go mod verify。测试覆盖 JWT 错受众/错算法、OIDC 错受众/到期/到期等于当前时刻/错 nonce、OIDC 错签名/发行者、缺 sub/iat、未来 iat、空期望 nonce、多受众与 azp,以及 JWT profile/issuer/必填声明、Casbin 其他用户/跨租户/错误对象/未授予动作/撤销。
  • govulncheck -show verbose ./... 成功,符号和导入包层面无发现,模块层面 1 项;DB 更新于 2026-10-08 22:31:09 UTC。gosec 退出码 1 且类型加载失败,不视为扫描通过。未启用任何扫描器的外部 AI 修复功能。
  • 未做真实 OAuth/OIDC、远程 JWKS 轮换、浏览器、分布式策略、Trivy 镜像、Vault、KMS 或生产风险验证。

完整复现代码与配置 ​

下列文件组成同一个模块,scanfixture/fixture.go 放入对应子目录。该函数只给静态扫描器看,程序和测试从不调用它。临时 RSA 私钥每次生成、不写入文件;所有密码、nonce、ID 与域名都为公开夹具。

go.mod ​

go.mod
module example.com/security07

go 1.27.2

require (
	github.com/casbin/casbin/v2 v2.135.0
	github.com/coreos/go-oidc/v3 v3.21.0
	github.com/golang-jwt/jwt/v5 v5.3.1
	golang.org/x/crypto v0.58.0
)

require (
	github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
	github.com/casbin/govaluate v1.3.0 // indirect
	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
	github.com/google/uuid v1.6.0 // indirect
	golang.org/x/oauth2 v0.36.0 // indirect
	golang.org/x/sys v0.49.0 // indirect
)

go.sum ​

text
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=

main.go ​

代码块 · 133 行 · 点击收起 / 展开
go
package main

import (
	"context"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/subtle"
	_ "embed"
	"errors"
	"fmt"
	"github.com/casbin/casbin/v2"
	"github.com/casbin/casbin/v2/model"
	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/argon2"
	"time"
)

//go:embed model.conf
var modelText string
var now = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)

const issuer = "https://issuer.example"
const publicNonce = "public-test-nonce-bound-to-one-transaction"

func token(key *rsa.PrivateKey, aud, nonce string, expires time.Time) string {
	c := jwt.MapClaims{"iss": issuer, "sub": "alice", "aud": aud, "iat": now.Add(-time.Second).Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": expires.Unix(), "jti": "public-test-id"}
	typ := "demo-access+jwt"
	if nonce != "" {
		c["nonce"] = nonce
		typ = "JWT"
	}
	t := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
	t.Header["typ"] = typ
	raw, err := t.SignedString(key)
	must(err)
	return raw
}
func checkJWT(raw string, pub *rsa.PublicKey) error {
	c := new(jwt.RegisteredClaims)
	parsed, err := jwt.ParseWithClaims(raw, c, func(t *jwt.Token) (any, error) {
		if t.Header["typ"] != "demo-access+jwt" {
			return nil, errors.New("profile")
		}
		return pub, nil
	}, jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer(issuer), jwt.WithAudience("orders-api"), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithTimeFunc(func() time.Time { return now }))
	if err != nil {
		return err
	}
	if !parsed.Valid || c.Subject == "" || c.ID == "" || c.IssuedAt == nil || c.NotBefore == nil {
		return errors.New("claims")
	}
	return nil
}
func verifier(pub *rsa.PublicKey) *oidc.IDTokenVerifier {
	return oidc.NewVerifier(issuer, &oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{pub}}, &oidc.Config{ClientID: "web-client", SupportedSigningAlgs: []string{"RS256"}, Now: func() time.Time { return now }})
}
func checkOIDC(v *oidc.IDTokenVerifier, raw, wantNonce string) error {
	id, err := v.Verify(context.Background(), raw)
	if err != nil {
		return err
	}
	if wantNonce == "" || subtle.ConstantTimeCompare([]byte(id.Nonce), []byte(wantNonce)) != 1 {
		return errors.New("nonce")
	}
	if id.Subject == "" || id.IssuedAt.IsZero() || id.IssuedAt.After(now.Add(time.Minute)) || !now.Before(id.Expiry) {
		return errors.New("application time/subject")
	}
	var extra struct {
		AZP string `json:"azp"`
	}
	if err = id.Claims(&extra); err != nil {
		return err
	}
	if len(id.Audience) > 1 && extra.AZP != "web-client" || extra.AZP != "" && extra.AZP != "web-client" {
		return errors.New("azp")
	}
	// A production callback must also atomically consume its transaction state.
	return nil
}

type Resource struct{ Name, Tenant string }

func enforcer() (*casbin.Enforcer, error) {
	m, err := model.NewModelFromString(modelText)
	if err != nil {
		return nil, err
	}
	e, err := casbin.NewEnforcer(m)
	if err != nil {
		return nil, err
	}
	if _, err = e.AddPolicy("reader", "team-a", "doc-1", "read"); err != nil {
		return nil, err
	}
	if _, err = e.AddGroupingPolicy("alice", "reader", "team-a"); err != nil {
		return nil, err
	}
	return e, nil
}
func main() {
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	must(err)
	pub := &key.PublicKey
	fmt.Println("jwt-valid:", checkJWT(token(key, "orders-api", "", now.Add(time.Minute)), pub) == nil)
	fmt.Println("jwt-wrong-audience:", checkJWT(token(key, "other-api", "", now.Add(time.Minute)), pub) == nil)
	v := verifier(pub)
	raw := token(key, "web-client", publicNonce, now.Add(time.Minute))
	fmt.Println("oidc-valid:", checkOIDC(v, raw, publicNonce) == nil)
	fmt.Println("oidc-wrong-nonce:", checkOIDC(v, raw, "other-transaction") == nil)
	e, err := enforcer()
	must(err)
	ok, err := e.Enforce("alice", "team-a", Resource{"doc-1", "team-a"}, "read")
	must(err)
	fmt.Println("casbin-read:", ok)
	ok, err = e.Enforce("alice", "team-a", Resource{"doc-1", "team-b"}, "read")
	must(err)
	fmt.Println("casbin-cross-tenant:", ok)
	ok, err = e.Enforce("alice", "team-a", Resource{"doc-1", "team-a"}, "delete")
	must(err)
	fmt.Println("casbin-delete:", ok)
	salt := make([]byte, 16)
	_, err = rand.Read(salt)
	must(err)
	dk := argon2.IDKey([]byte("public test password"), salt, 2, 19*1024, 1, 32)
	fmt.Println("argon2-output-bytes:", len(dk))
}
func must(err error) {
	if err != nil {
		panic(err)
	}
}

main_test.go ​

代码块 · 154 行 · 点击收起 / 展开
go
package main

import (
	"crypto/rand"
	"crypto/rsa"
	"github.com/golang-jwt/jwt/v5"
	"testing"
	"time"
)

func TestJWTAndOIDC(t *testing.T) {
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatal(err)
	}
	pub := &key.PublicKey
	if checkJWT(token(key, "orders-api", "", now.Add(time.Minute)), pub) != nil {
		t.Fatal("valid jwt")
	}
	if checkJWT(token(key, "other-api", "", now.Add(time.Minute)), pub) == nil {
		t.Fatal("wrong audience")
	}
	h := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"iss": issuer, "aud": "orders-api", "exp": now.Add(time.Minute).Unix()})
	raw, err := h.SignedString([]byte("PUBLIC TEST KEY, NOT A SECRET"))
	if err != nil {
		t.Fatal(err)
	}
	if checkJWT(raw, pub) == nil {
		t.Fatal("wrong algorithm")
	}
	v := verifier(pub)
	raw = token(key, "web-client", publicNonce, now.Add(time.Minute))
	if err = checkOIDC(v, raw, publicNonce); err != nil {
		t.Fatal(err)
	}
	for name, raw := range map[string]string{"wrong-aud": token(key, "other-client", publicNonce, now.Add(time.Minute)), "expired": token(key, "web-client", publicNonce, now.Add(-time.Second)), "expiry-equal": token(key, "web-client", publicNonce, now)} {
		if checkOIDC(v, raw, publicNonce) == nil {
			t.Fatal(name)
		}
	}
	if checkOIDC(v, raw, "wrong") == nil {
		t.Fatal("nonce mismatch")
	}
}
func TestPolicy(t *testing.T) {
	e, err := enforcer()
	if err != nil {
		t.Fatal(err)
	}
	for _, tt := range []struct {
		user, dom string
		r         Resource
		action    string
		want      bool
	}{{"alice", "team-a", Resource{"doc-1", "team-a"}, "read", true}, {"bob", "team-a", Resource{"doc-1", "team-a"}, "read", false}, {"alice", "team-a", Resource{"doc-1", "team-b"}, "read", false}, {"alice", "team-b", Resource{"doc-1", "team-b"}, "read", false}, {"alice", "team-a", Resource{"doc-1", "team-a"}, "delete", false}} {
		got, err := e.Enforce(tt.user, tt.dom, tt.r, tt.action)
		if err != nil || got != tt.want {
			t.Fatal(tt, got, err)
		}
	}
	if _, err = e.RemoveGroupingPolicy("alice", "reader", "team-a"); err != nil {
		t.Fatal(err)
	}
	if ok, err := e.Enforce("alice", "team-a", Resource{"doc-1", "team-a"}, "read"); err != nil || ok {
		t.Fatal("revocation", ok, err)
	}
}

func TestOIDCApplicationBoundaries(t *testing.T) {
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatal(err)
	}
	v := verifier(&key.PublicKey)
	base := func() jwt.MapClaims {
		return jwt.MapClaims{"iss": issuer, "sub": "alice", "aud": "web-client", "iat": now.Add(-time.Second).Unix(), "exp": now.Add(3 * time.Minute).Unix(), "nonce": publicNonce}
	}
	sign := func(c jwt.MapClaims, k *rsa.PrivateKey) string {
		raw, err := jwt.NewWithClaims(jwt.SigningMethodRS256, c).SignedString(k)
		if err != nil {
			t.Fatal(err)
		}
		return raw
	}
	cases := []struct {
		name   string
		mutate func(jwt.MapClaims)
		want   bool
	}{
		{"wrong-issuer", func(c jwt.MapClaims) { c["iss"] = "https://other.example" }, false},
		{"missing-sub", func(c jwt.MapClaims) { delete(c, "sub") }, false},
		{"missing-iat", func(c jwt.MapClaims) { delete(c, "iat") }, false},
		{"future-iat", func(c jwt.MapClaims) { c["iat"] = now.Add(2 * time.Minute).Unix() }, false},
		{"multi-aud-no-azp", func(c jwt.MapClaims) { c["aud"] = []string{"web-client", "other"} }, false},
		{"multi-aud-wrong-azp", func(c jwt.MapClaims) { c["aud"] = []string{"web-client", "other"}; c["azp"] = "other" }, false},
		{"multi-aud-correct-azp", func(c jwt.MapClaims) { c["aud"] = []string{"web-client", "other"}; c["azp"] = "web-client" }, true},
		{"single-aud-wrong-azp", func(c jwt.MapClaims) { c["azp"] = "other" }, false},
	}
	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			c := base()
			tt.mutate(c)
			err := checkOIDC(v, sign(c, key), publicNonce)
			if (err == nil) != tt.want {
				t.Fatal(err)
			}
		})
	}
	if checkOIDC(v, sign(base(), key), "") == nil {
		t.Fatal("empty expected nonce")
	}
	other, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatal(err)
	}
	if checkOIDC(v, sign(base(), other), publicNonce) == nil {
		t.Fatal("wrong signing key")
	}
}
func TestJWTProfileAndClaims(t *testing.T) {
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatal(err)
	}
	for _, name := range []string{"typ", "issuer", "expired", "sub", "jti", "iat", "nbf", "exp"} {
		c := jwt.MapClaims{"iss": issuer, "sub": "alice", "aud": "orders-api", "iat": now.Add(-time.Second).Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": now.Add(time.Minute).Unix(), "jti": "test-id"}
		token := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
		token.Header["typ"] = "demo-access+jwt"
		switch name {
		case "typ":
			token.Header["typ"] = "JWT"
		case "issuer":
			c["iss"] = "https://other.example"
		case "expired":
			c["exp"] = now.Add(-time.Second).Unix()
		default:
			delete(c, name)
		}
		raw, err := token.SignedString(key)
		if err != nil {
			t.Fatal(err)
		}
		if checkJWT(raw, &key.PublicKey) == nil {
			t.Fatal("accepted", name)
		}
	}
	e, err := enforcer()
	if err != nil {
		t.Fatal(err)
	}
	if ok, err := e.Enforce("alice", "team-a", Resource{"doc-2", "team-a"}, "read"); err != nil || ok {
		t.Fatal("wrong object", ok, err)
	}
}

model.conf ​

text
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.dom == r.obj.Tenant && g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj.Name == p.obj && r.act == p.act

scanfixture/fixture.go ​

go
// Package scanfixture is deliberately unsafe STATIC INPUT for a scanner.
// It does not dial, serve, or change any system security setting.
package scanfixture

import "crypto/tls"

func NeverUseThisConfig() *tls.Config {
	return &tls.Config{InsecureSkipVerify: true}
}

运行命令 ​

sh
go mod download
go run .
go test -race -count=1 ./...
go vet ./...

本次扫描的复现命令 ​

以下安装到项目内 .tools,没有把二进制写入系统目录。默认使用官方 Go 模块代理/校验配置;若网络受限,应使用已核验的本地缓存,不能把失败当作通过。

sh
GOBIN="$PWD/.tools" go install golang.org/x/vuln/cmd/govulncheck@v1.9.0
GOBIN="$PWD/.tools" go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0
go version -m .tools/govulncheck
go version -m .tools/gosec
.tools/govulncheck -version
.tools/govulncheck -show verbose ./...
# 本文 Go 1.27.2 下这一步实际返回 1,JSON 中有类型加载错误。
# 不加 -no-fail,不隐藏失败,也不启用 AI 自动修复参数。
.tools/gosec -include=G402 -fmt=json -out=gosec.json ./scanfixture

Trivy 命令形态(本文未运行) ​

须先按官方文档安装适合当前环境的版本,记录 trivy --version 与数据库状态。IMAGE_REF 应是已核验的最终镜像 digest,以下命令并非本篇实测结果。

sh
trivy --version
trivy fs --scanners vuln,misconfig,secret .
: "${IMAGE_REF:?set a verified image digest}"
trivy image --scanners vuln,secret "$IMAGE_REF"
trivy image --format cyclonedx --output sbom.cdx.json "$IMAGE_REF"
trivy sbom sbom.cdx.json

保留各书来源与许可 · 示例运行条件以正文为准